Making a blogging system with Phoenix and React [Part 4]
making-a-blogging-system-with-phoenix-and-react-[part-4]Making use of our API
Hello and welcome to part 4 ! This is the last part so congratulations if you have made it this far !
In our previous post, we added the ability to write blog posts in our Phoenix back-office.
In this post, we're scaffolding our API and displaying it all in the front-end (React in my case).
What we are aiming to do is :
Fetch all our published posts in the front-end
Display them in a list to the user
Fetch one post
Display it nicely formatted to the user
Slugify the title
I know I said we were done with the back-end setup. I lied.
There are a few more things we need to do in order to ensure a more user-friendly experience.
As of right now, to fetch our posts in the database we use the Phoenix default function that depends on the post's ID. Depending on how you setup your database, it could be 123
if you used integer IDs or it could be 287632fb-018b-448f-a9e2-d953702dc759
if you use UUIDs.
However, I want to make sure of two things:
That our post can easily be found via URL (no one wants to type
example.com/posts/28763fb-018b...
)That our post title will always be unique.
We are, after all, the sole authors of this blog, there's no reason why two posts would have the exact same title.
Note that if you are planning to write extremely long post titles, you are better off not following this part but instead fetching through the ID
To do that, we are going to slugify our post title and add a get_post_by_slug!/1
function to our app.
We start by adding the slug column to our posts with mix ecto.gen.migration add_slug_to_posts
. This will add a file in app/priv/repo/migrations
. Open this file and alter the contents to match:
defmodule App.Repo.Migrations.AddSlugToPosts do
use Ecto.Migration
def change do
+ alter table(:posts) do
+ add :slug, :string
+ end
end
end
Then we'll update our schema in app/lib/app/blog/post.ex
, create our slugify_title/2
function as well as modify our changeset to make sure that our slug is unique:
defmodule App.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
use Waffle.Ecto.Schema
schema "posts" do
field :title, :string
field :content, :string
field :published_at, :naive_datetime
field :tags, :string
field :feature_image, App.FeatureImage.Type
+ field :slug, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content, :published_at, :tags])
+ |> validate_required([:title, :content, :published_at, :tags, :slug])
+ |> unique_constraint(:slug)
end
#If the title is part of our changeset, this function will match...
def slugify_title(post, %{"title" => title}) when is_binary(title) do
# /!\ I did NOT come up with this recipe but I failed to write down the source. If this is your bit of code, let me know so I can add the proper credits
slug = title
|> String.normalize(:nfc)
|> String.downcase()
|> String.trim()
|> String.replace([" ",",",";"], "-")
|> String.replace(~r/-{2,}/, "-") # If you don't speak regex, this means "replace any -- by a -"
|> String.trim("-") # and here we remove any extra trailing - (beginning or end)
post
|> put_change(:slug, slug)
end
#...otherwise we do nothing with the slug
def slugify_title(post, %{}) do
post
end
Note: Using slugs is good for SEO, however if the title (and the slug) came to change, this would break a link and impact your score. Ideally you'd still fetch based on a unique, immutable ID-like value that can be parsed from the URL. Lucky for you, Hashrocket has that exact tutorial.
Now we are ready to migrate with the command mix ecto.migrate
(do that) and implement our last two functions (I promise). We're opening our app/lib/app/blog.ex
file and adding the functions get_post_by_slug!/1
and list_published_posts/0
. We need this last one to prevent any technically unpublished posts from leaking to the front-end (remember that we have a published_at
field in our posts). We also make sure to show the latest posts first with order_by: [desc: p.published_at]
.
# app/lib/app/blog.ex
@doc"""
Returns the list of published posts
"""
def list_published_posts do
now = NaiveDateTime.local_now()
query = from(p in Post, where: p.published_at < ^now, order_by: [desc: p.published_at])
Repo.all(query)
end
@doc"""
Get a post by slug
"""
def get_post_by_slug!(slug) do
Repo.get_by!(Post, slug: slug)
end
#...rest of file
Okay now we're done with adding functions, we can scaffold away !
Scaffolding the API routes
Generate the controller and JSON
Once again, Phoenix makes this easy for us. We'll just have to repeat our post schema so that the proper JSON is generated :
mix phx.gen.json --no-schema --no-context Blog Post posts title:string content:string published_at:naive_datetime tags:string feature_image:string slug:string --web Api
Notice the last parameter "--web Api". This will generate a subdirectory in our
app/lib/app_web/controllers/
called "Api" where our JSON and controller will live. This makes it easier to manage both types of resources.
Now let's add our routes to the router.ex
file. We only need two, under the :api
scope:
#app/lib/app_web/router.ex
scope "/api", App do
pipe_through :api
get "/posts", Api.PostController, :index
get "/posts/:slug", Api.PostController, :show
end
Make use of our functions
Let's head into our app/lib/app_web/controllers/api/post_controller
file and use the two functions we created just before:
#app/lib/app_web/controllers/api/post_controller.ex
#...beginning of file
def index(conn, _params) do
posts = Blog.list_published_posts() # Make this modification
render(conn, :index, posts: posts)
end
def show(conn, %{"slug" => slug}) do
post = Blog.get_post_by_slug!(slug) # Make this modification
render(conn, :show, post: post)
end
#...rest of file
Note: We don't use any of the create/update/delete functions in the API, feel free to delete those.
Nice ! Now we should be able to list our published posts by making a GET request to localhost:4000/api/posts
and to retrieve a single post by making another GET request to localhost:4000/api/posts/my-post-title
.
> http localhost:4000/api/posts
>>> HTTP/1.1 200 OK
>>> cache-control: max-age=0, private, must-revalidate
>>> content-length: 1568
>>> content-type: application/json; charset=utf-8
>>> date: Fri, 18 Oct 2024 00:34:07 GMT
>>> server: Cowboy
>>> x-request-id: F_9k1SDj20sQ8m8AAAmI
>>> {
>>> "data": [
>>> {
>>> "content": "<p>My content</p>",
>>> "feature_image": {
>>> "original": "https://{mybucket}/uploads/6_original_post_image.jpg?v=63895580273",
>>> "thumb": "https://{mybucket}/uploads/6_thumb_post_image.jpg?v=63895580273"
>>> },
>>> "id": 6,
>>> "published_at": "2024-10-01T08:00:00",
>>> "slug": "my-other-post",
>>> "tags": "tag,tag,tag",
>>> "title": "My other post"
>>> },
>>> {
>>> "content": "<p>Test content </p>",
>>> "feature_image": {
>>> "original": "https://{mybucket}/uploads/5_original_post_image.jpg?v=63895580273",
>>> "thumb": "https://{mybucket}/uploads/5_thumb_post_image.jpg?v=63895580273"
>>> },
>>> "id": 5,
>>> "published_at": "2024-10-02T06:00:00",
>>> "slug": "my-new-post",
>>> "tags": "tag,tag,tag",
>>> "title": "My new post"
>>> },
>>> ...
>>> ]
>>> }
> http localhost:4000/api/posts/my-new-post
>>> HTTP/1.1 200 OK
>>> cache-control: max-age=0, private, must-revalidate
>>> content-length: 377
>>> content-type: application/json; charset=utf-8
>>> date: Fri, 18 Oct 2024 00:37:43 GMT
>>> server: Cowboy
>>> x-request-id: F_9lB0uLucaAbwAAAABB
>>>
>>> {
>>> "data": {
>>> "content": "<p>Test content beep</p>",
>>> "feature_image": {
>>> "original": "https://{mybucket}/uploads/5_original_post_image.jpg?v=63895580273",
>>> "thumb": "https://{mybucket}/uploads/5_thumb_post_image.jpg?v=63895580273"
>>> },
>>> "id": 5,
>>> "published_at": "2024-10-02T06:00:00",
>>> "slug": "my-new-post",
>>> "tags": "tag,tag,tag",
>>> "title": "My new post"
>>> }
>>> }
Onto the frontend
I default to React out of bad habit but at this point this is just some AJAX and rendering which you can implement however you want (how about a little bit of Svelte or maybe some Astro ?).
Listing the posts
I will spare you the details of setting up a React app. There has to be millions of tutorials on that by now.
Just know that these are the packages I use and that you might need to install or find an alternative to :
axios
dompurify
hightlight.js
react-router
react-router-dom
We create the Blog.jsx
component. It will make the call to our back-end to retrieve all posts and display them in a loop.
import { useEffect, useState } from "react";
import axios from "axios";
import { Link } from "react-router-dom";
const Blog = () => {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
async function fetchData() {
setLoading(true);
try {
const res = await axios.get("http://localhost:4000/api/posts"); // For the sake of this tutorial, I'm not taking my usual shortcut of creating an axios instance with a base url here
setData(res.data.data);
setLoading(false);
} catch (error: any) {
setLoading(false);
console.trace(error);
// Do something with the error
}
}
useEffect(() => {
fetchData();
}, []);
return (
<section id="blog">
<h1>Blog posts<h1>
{loading && <div>Loading...</div>}
<div className="posts">
{data.map((post) => {
return (
<article key={post.id}>
<img src={post.feature_image.thumb} />
<div>
<h4>{post.title}</h4>
<Link to={"/post/" + post.slug}> {/*Our frontend link to the post*/}
<button>Read more</button>
</Link>
</div>
</article>
);
})}
</div>
</section>
);
};
export default Blog;
I will go ahead and use this component in my Main.jsx
component that groups all the different parts of my app.
Displaying a post
Then another component, Post.tsx
to display the actual post:
import { useParams } from "react-router";
import DOMPurify from "dompurify";
import hljs from "highlight.js/lib/core";
// My theme of choice for the code blocks
import "highlight.js/styles/base16/zenburn.min.css";
// Here I list the languages I mostly use to keep the bundle size down
import javascript from "highlight.js/lib/languages/javascript";
import elixir from "highlight.js/lib/languages/elixir";
import css from "highlight.js/lib/languages/css";
import { useEffect, useState } from "react";
const Post = () => {
const [data, setData] = useState({});
const [loading, setLoading] = useState(false);
const {slug} = useParams()
async function fetchData() {
setLoading(true);
try {
const res = await axios.get("http://localhost:4000/api/posts/" + slug); // For the sake of this tutorial, I'm not taking my usual shortcut of creating an axios instance with a base url here
setData(res.data.data);
setLoading(false);
} catch (error: any) {
setLoading(false);
console.trace(error);
// Do something with the error
}
}
useEffect(() => {
fetchData();
}, []);
// Registers our selected languages in hljs for highlighting
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("elixir", elixir);
hljs.registerLanguage("css", css);
useEffect(() => {
hljs.highlightAll();
}, []);
if(loading) {
return (<div>Loading post...</div>)
}
return (
<article id="post">
<img src={post.feature_image.original} />
<header>
<h2>{post.title}</h2>
<sup>{post.slug}</sup>
</header>
{/* We might be the authors of the HTML but it's good practice to treat setting raw HTML as dangerous.Here we use DOMPurify to sanitize it */}
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(post.content),
}}
id="content"
/>
</article>
);
};
export default Post;
Finally, adding these two components to our routing.
//App.jsx or wherever your routes are
<Routes>
<Route
path="/post/:slug"
element={
<Post />
}
/>
<Route path="*" element={<Main />} /> {/*Remember that Blog.jsx lives in Main.jsx*/}
</Routes>
);
All you have to do now is create some styling for your blog posts and we're done ! We can now:
Create a blog post in our Phoenix back-office
Attach an image to it
Query for posts on our React front-end
Display said post under a user-friendly slugified URL
Hope you enjoyed :)
Troubleshooting
CORS Errors
If you are stuck behind a CORS error or a 302 response from Phoenix, try installing the CORS plug and setting it up to allow your origin.